aboutsummaryrefslogtreecommitdiffstats
path: root/src/app/groups/[groupId]
diff options
context:
space:
mode:
Diffstat (limited to 'src/app/groups/[groupId]')
-rw-r--r--src/app/groups/[groupId]/expenses/create-from-receipt-button-actions.ts48
-rw-r--r--src/app/groups/[groupId]/expenses/create-from-receipt-button.tsx278
-rw-r--r--src/app/groups/[groupId]/expenses/expense-list.tsx11
-rw-r--r--src/app/groups/[groupId]/expenses/page.tsx13
4 files changed, 340 insertions, 10 deletions
diff --git a/src/app/groups/[groupId]/expenses/create-from-receipt-button-actions.ts b/src/app/groups/[groupId]/expenses/create-from-receipt-button-actions.ts
new file mode 100644
index 0000000..1714f82
--- /dev/null
+++ b/src/app/groups/[groupId]/expenses/create-from-receipt-button-actions.ts
@@ -0,0 +1,48 @@
+'use server'
+import { getCategories } from '@/lib/api'
+import { env } from '@/lib/env'
+import OpenAI from 'openai'
+
+const openai = new OpenAI({ apiKey: env.OPENAI_API_KEY })
+
+export async function extractExpenseInformationFromImage(imageUrl: string) {
+ 'use server'
+ const categories = await getCategories()
+
+ const body = {
+ model: 'gpt-4-vision-preview',
+ messages: [
+ {
+ role: 'user',
+ content: [
+ {
+ type: 'text',
+ text: `
+ This image contains a receipt.
+ Read the total amount and store it as a non-formatted number without any other text or currency.
+ Then guess the category for this receipt amoung the following categories and store its ID: ${categories.map(
+ ({ id, grouping, name }) => `"${grouping}/${name}" (ID: ${id})`,
+ )}.
+ Guess the expense’s date and store it as yyyy-mm-dd.
+ Guess a title for the expense.
+ Return the amount, the category, the date and the title with just a comma between them, without anything else.`,
+ },
+ ],
+ },
+ {
+ role: 'user',
+ content: [{ type: 'image_url', image_url: { url: imageUrl } }],
+ },
+ ],
+ }
+ const completion = await openai.chat.completions.create(body as any)
+
+ const [amountString, categoryId, date, title] = completion.choices
+ .at(0)
+ ?.message.content?.split(',') ?? [null, null, null, null]
+ return { amount: Number(amountString), categoryId, date, title }
+}
+
+export type ReceiptExtractedInfo = Awaited<
+ ReturnType<typeof extractExpenseInformationFromImage>
+>
diff --git a/src/app/groups/[groupId]/expenses/create-from-receipt-button.tsx b/src/app/groups/[groupId]/expenses/create-from-receipt-button.tsx
new file mode 100644
index 0000000..5feb931
--- /dev/null
+++ b/src/app/groups/[groupId]/expenses/create-from-receipt-button.tsx
@@ -0,0 +1,278 @@
+'use client'
+
+import { CategoryIcon } from '@/app/groups/[groupId]/expenses/category-icon'
+import {
+ ReceiptExtractedInfo,
+ extractExpenseInformationFromImage,
+} from '@/app/groups/[groupId]/expenses/create-from-receipt-button-actions'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '@/components/ui/dialog'
+import {
+ Drawer,
+ DrawerContent,
+ DrawerDescription,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from '@/components/ui/drawer'
+import { ToastAction } from '@/components/ui/toast'
+import { useToast } from '@/components/ui/use-toast'
+import { useMediaQuery } from '@/lib/hooks'
+import { formatExpenseDate } from '@/lib/utils'
+import { Category } from '@prisma/client'
+import { ChevronRight, Loader2, Receipt } from 'lucide-react'
+import { getImageData, useS3Upload } from 'next-s3-upload'
+import Image from 'next/image'
+import { useRouter } from 'next/navigation'
+import { PropsWithChildren, ReactNode, useState } from 'react'
+
+type Props = {
+ groupId: string
+ groupCurrency: string
+ categories: Category[]
+}
+
+export function CreateFromReceiptButton({
+ groupId,
+ groupCurrency,
+ categories,
+}: Props) {
+ const [pending, setPending] = useState(false)
+ const { uploadToS3, FileInput, openFileDialog } = useS3Upload()
+ const { toast } = useToast()
+ const router = useRouter()
+ const [receiptInfo, setReceiptInfo] = useState<
+ | null
+ | (ReceiptExtractedInfo & { url: string; width?: number; height?: number })
+ >(null)
+ const isDesktop = useMediaQuery('(min-width: 640px)')
+
+ const handleFileChange = async (file: File) => {
+ const upload = async () => {
+ try {
+ setPending(true)
+ console.log('Uploading image…')
+ let { url } = await uploadToS3(file)
+ console.log('Extracting information from receipt…')
+ const { amount, categoryId, date, title } =
+ await extractExpenseInformationFromImage(url)
+ const { width, height } = await getImageData(file)
+ setReceiptInfo({ amount, categoryId, date, title, url, width, height })
+ } catch (err) {
+ console.error(err)
+ toast({
+ title: 'Error while uploading document',
+ description:
+ 'Something wrong happened when uploading the document. Please retry later or select a different file.',
+ variant: 'destructive',
+ action: (
+ <ToastAction altText="Retry" onClick={() => upload()}>
+ Retry
+ </ToastAction>
+ ),
+ })
+ } finally {
+ setPending(false)
+ }
+ }
+ upload()
+ }
+
+ const receiptInfoCategory =
+ (receiptInfo?.categoryId &&
+ categories.find((c) => String(c.id) === receiptInfo.categoryId)) ||
+ null
+
+ const DialogOrDrawer = isDesktop
+ ? CreateFromReceiptDialog
+ : CreateFromReceiptDrawer
+
+ return (
+ <DialogOrDrawer
+ trigger={
+ <Button
+ size="icon"
+ variant="secondary"
+ title="Create expense from receipt"
+ >
+ <Receipt className="w-4 h-4" />
+ </Button>
+ }
+ title={
+ <>
+ <span>Create from receipt</span>
+ <Badge className="bg-pink-700 hover:bg-pink-600 dark:bg-pink-500 dark:hover:bg-pink-600">
+ Beta
+ </Badge>
+ </>
+ }
+ description={<>Extract the expense information from a receipt photo.</>}
+ >
+ <div className="prose prose-sm dark:prose-invert">
+ <p>
+ Upload the photo of a receipt, and we’ll scan it to extract the
+ expense information if we can.
+ </p>
+ <div>
+ <FileInput
+ onChange={handleFileChange}
+ accept="image/jpeg,image/png"
+ />
+ <div className="grid gap-x-4 gap-y-2 grid-cols-3">
+ <Button
+ variant="secondary"
+ className="row-span-3 w-full h-full relative"
+ title="Create expense from receipt"
+ onClick={openFileDialog}
+ disabled={pending}
+ >
+ {pending ? (
+ <Loader2 className="w-8 h-8 animate-spin" />
+ ) : receiptInfo ? (
+ <div className="absolute top-2 left-2 bottom-2 right-2">
+ <Image
+ src={receiptInfo.url}
+ width={receiptInfo.width}
+ height={receiptInfo.height}
+ className="w-full h-full m-0 object-contain drop-shadow-lg"
+ alt="Scanned receipt"
+ />
+ </div>
+ ) : (
+ <span className="text-xs sm:text-sm text-muted-foreground">
+ Select image…
+ </span>
+ )}
+ </Button>
+ <div className="col-span-2">
+ <strong>Title:</strong>
+ <div>{receiptInfo?.title ?? '…'}</div>
+ </div>
+ <div className="col-span-2">
+ <strong>Category:</strong>
+ <div>
+ {receiptInfoCategory ? (
+ <div className="flex items-center">
+ <CategoryIcon
+ category={receiptInfoCategory}
+ className="inline w-4 h-4 mr-2"
+ />
+ <span className="mr-1">{receiptInfoCategory.grouping}</span>
+ <ChevronRight className="inline w-3 h-3 mr-1" />
+ <span>{receiptInfoCategory.name}</span>
+ </div>
+ ) : (
+ '' || '…'
+ )}
+ </div>
+ </div>
+ <div>
+ <strong>Amount:</strong>
+ <div>
+ {receiptInfo?.amount ? (
+ <>
+ {groupCurrency} {receiptInfo.amount.toFixed(2)}
+ </>
+ ) : (
+ '…'
+ )}
+ </div>
+ </div>
+ <div>
+ <strong>Date:</strong>
+ <div>
+ {receiptInfo?.date
+ ? formatExpenseDate(
+ new Date(`${receiptInfo?.date}T12:00:00.000Z`),
+ )
+ : '…'}
+ </div>
+ </div>
+ </div>
+ </div>
+ <p>You’ll be able to edit the expense information after creating it.</p>
+ <div className="text-center">
+ <Button
+ disabled={pending || !receiptInfo}
+ onClick={() => {
+ if (!receiptInfo) return
+ router.push(
+ `/groups/${groupId}/expenses/create?amount=${
+ receiptInfo.amount
+ }&categoryId=${receiptInfo.categoryId}&date=${
+ receiptInfo.date
+ }&title=${encodeURIComponent(
+ receiptInfo.title ?? '',
+ )}&imageUrl=${encodeURIComponent(receiptInfo.url)}&imageWidth=${
+ receiptInfo.width
+ }&imageHeight=${receiptInfo.height}`,
+ )
+ }}
+ >
+ Create expense
+ </Button>
+ </div>
+ </div>
+ </DialogOrDrawer>
+ )
+}
+
+function CreateFromReceiptDialog({
+ trigger,
+ title,
+ description,
+ children,
+}: PropsWithChildren<{
+ trigger: ReactNode
+ title: ReactNode
+ description: ReactNode
+}>) {
+ return (
+ <Dialog>
+ <DialogTrigger asChild>{trigger}</DialogTrigger>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">{title}</DialogTitle>
+ <DialogDescription className="text-left">
+ {description}
+ </DialogDescription>
+ </DialogHeader>
+ {children}
+ </DialogContent>
+ </Dialog>
+ )
+}
+
+function CreateFromReceiptDrawer({
+ trigger,
+ title,
+ description,
+ children,
+}: PropsWithChildren<{
+ trigger: ReactNode
+ title: ReactNode
+ description: ReactNode
+}>) {
+ return (
+ <Drawer>
+ <DrawerTrigger asChild>{trigger}</DrawerTrigger>
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle className="flex items-center gap-2">{title}</DrawerTitle>
+ <DrawerDescription className="text-left">
+ {description}
+ </DrawerDescription>
+ </DrawerHeader>
+ <div className="px-4 pb-4">{children}</div>
+ </DrawerContent>
+ </Drawer>
+ )
+}
diff --git a/src/app/groups/[groupId]/expenses/expense-list.tsx b/src/app/groups/[groupId]/expenses/expense-list.tsx
index 0dc7a7e..a97c2bd 100644
--- a/src/app/groups/[groupId]/expenses/expense-list.tsx
+++ b/src/app/groups/[groupId]/expenses/expense-list.tsx
@@ -3,7 +3,7 @@ import { CategoryIcon } from '@/app/groups/[groupId]/expenses/category-icon'
import { Button } from '@/components/ui/button'
import { SearchBar } from '@/components/ui/search-bar'
import { getGroupExpenses } from '@/lib/api'
-import { cn } from '@/lib/utils'
+import { cn, formatExpenseDate } from '@/lib/utils'
import { Expense, Participant } from '@prisma/client'
import dayjs, { type Dayjs } from 'dayjs'
import { ChevronRight } from 'lucide-react'
@@ -159,7 +159,7 @@ export function ExpenseList({
{currency} {(expense.amount / 100).toFixed(2)}
</div>
<div className="text-xs text-muted-foreground">
- {formatDate(expense.expenseDate)}
+ {formatExpenseDate(expense.expenseDate)}
</div>
</div>
<Button
@@ -189,10 +189,3 @@ export function ExpenseList({
</p>
)
}
-
-function formatDate(date: Date) {
- return date.toLocaleDateString('en-US', {
- dateStyle: 'medium',
- timeZone: 'UTC',
- })
-}
diff --git a/src/app/groups/[groupId]/expenses/page.tsx b/src/app/groups/[groupId]/expenses/page.tsx
index 0c36166..9dc3ab5 100644
--- a/src/app/groups/[groupId]/expenses/page.tsx
+++ b/src/app/groups/[groupId]/expenses/page.tsx
@@ -1,5 +1,6 @@
import { cached } from '@/app/cached-functions'
import { ActiveUserModal } from '@/app/groups/[groupId]/expenses/active-user-modal'
+import { CreateFromReceiptButton } from '@/app/groups/[groupId]/expenses/create-from-receipt-button'
import { ExpenseList } from '@/app/groups/[groupId]/expenses/expense-list'
import { Button } from '@/components/ui/button'
import {
@@ -10,7 +11,8 @@ import {
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
-import { getGroupExpenses } from '@/lib/api'
+import { getCategories, getGroupExpenses } from '@/lib/api'
+import { env } from '@/lib/env'
import { Download, Plus } from 'lucide-react'
import { Metadata } from 'next'
import Link from 'next/link'
@@ -31,6 +33,8 @@ export default async function GroupExpensesPage({
const group = await cached.getGroup(groupId)
if (!group) notFound()
+ const categories = await getCategories()
+
return (
<>
<Card className="mb-4 rounded-none -mx-4 border-x-0 sm:border-x sm:rounded-lg sm:mx-0">
@@ -51,6 +55,13 @@ export default async function GroupExpensesPage({
<Download className="w-4 h-4" />
</Link>
</Button>
+ {env.NEXT_PUBLIC_ENABLE_RECEIPT_EXTRACT && (
+ <CreateFromReceiptButton
+ groupId={groupId}
+ groupCurrency={group.currency}
+ categories={categories}
+ />
+ )}
<Button asChild size="icon">
<Link href={`/groups/${groupId}/expenses/create`}>
<Plus className="w-4 h-4" />